iT邦幫忙

2023 iThome 鐵人賽

DAY 6
0

上一篇完成了基本也完整的功能,我們繼續往下之前,先停一下腳步,回顧一下我們代碼的品質,rust提供clippy這個工具,可以有效的幫我們檢查代碼寫的不夠好的部分,並給予相關建議。

Cargo Clippy

Rust裡有個工具叫Clippy,理論上應該已經在安裝rust的時候就裝好了,如果沒有的話,快看這裡安裝,好了我們來掃掃看吧:

~/demo-app$ rustup component add clippy # 如果沒安裝到的話才需要跑這行
~/demo-app$ cargo clippy

使用range來取代多個邏輯

跑完說我們看一下提示的訊息,中間help後面說可以造訪他提供的網址,我們點開來看說明及建議如下:

clippy對於range使用建議提示

建議說我們用range表達會有更好的可讀性,range在上一篇的進階寫法有短暫現身過:

let mut indices: [usize; 9] = (1..=9).collect::<Vec<_>>().try_into().unwrap(); 

先講一下range語法,使用2個「.」符號接起來的數字就是範圍,並且是計頭不計尾,如果要計尾就加等號變..=,如下示例:

1..9                       	      // => 1到8
1..=9                     	      // => 1到9
let a = String::from("1234567");  
let b = &a[3..5];                 // 借用a,取第3至第5(不含)字元的概念
println!("{:?}", b);              // 45

除了告訴我們這個問題怎麼修比較好,一些簡單的問題還可以自動修正(好像在用eslint一樣),我們依提示執行下列命令即可:

~/demo-app$ cargo clippy --fix --bin "play"

單元測試

rust的單元測試工具內建,不用另外安裝,而且可以寫在cod旁邊,很容易參照,作法如下:

// 你的程式碼

#[cfg(test)]      // 告訴編譯器這是測試模組,所以在編譯代碼會忽略,只有跑測試才會編譯
mod test {        // 你要別的名字也可以只是一般我們寫test比較清晰
    // 你的測試代碼
}
  • #[cfg(test)] 的內容不會編譯到production code裡

寫完測試整體結構就像這樣:

程式碼結構圖

這邊的測試代碼9成以上是副駕駛幫忙完成的,我只列部分讓大家參考一下測試的代碼:

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn test_check_winner() {
        let mut game = Game::default();
        game.cells = [
            Some(Symbol::O), Some(Symbol::O), Some(Symbol::O),
            Some(Symbol::X), Some(Symbol::X), None,
            None, None, None,
        ];
        assert_eq!(game.check_winner(), Some(Symbol::O));
    }

    #[test]
    fn test_check_over_having_winner() {
        let mut game = Game::default();
        game.cells = [
            Some(Symbol::O), Some(Symbol::O), Some(Symbol::O),
            Some(Symbol::X), Some(Symbol::X), None,
            None, None, None,
        ];
        game.check_over();
        assert_eq!(game.is_over, true);
        assert_eq!(game.winner, Some(Symbol::O));
    }

    #[test]
    fn test_current_step() {
        let mut game = Game::default();
        game.cells = [
            Some(Symbol::O), Some(Symbol::O), Some(Symbol::X),
            Some(Symbol::X), Some(Symbol::X), Some(Symbol::O),
            Some(Symbol::O), None, None,
        ];
        assert_eq!(game.current_step(), 7);
    }
}

單元測試的重要性和介紹文章很多,就算你不想要當單元測試高手,你再怎麼討厭測試,你還是要會,相信未來的你會感激過去有寫測試的你。什麼?我講沒說服力,那只好有請大神來說「你就是不寫測試才會沒時間」了。

快速帶過單元測試的3A原則:「Arrange, Act, Assert」,「準備、執行、檢查」,上面的範例不外乎先準備前置資料(Arrange),再執行我們的受測代碼(Act),最後驗證是否符合預期的結果(Assert)。

接著對照上面的rust代碼,複習一下之前說的mod參照同目錄底下同名rs文件,或是同名資料夾底下的mod.rs文件,這只是跨檔案引入模組的慣例,而在檔案內直接宣告一個模組mod test也可以,mod block 包起來的部分就是獨立一個子模組範例如下:

mod test {
    use crate::tic_tac_toe::{Game, Symbol}; // 使用絕對路徑,或者是:
    use super::{Game, Symbol};              // 使用相對路徑,super 代表上一層
}

這個測試模組有加 #[cfg(test)]所以實際編譯的成品(production code)不會包含測試的部分,平常也不會編譯這裡面的內容,只有我們在跑test的時候才會跑,所以不用擔心都放在同一個檔案,會不會增加最後執行檔的大小,或讓編譯的時間變慢。

至於最後的assert_eq!,相信大家看到!,就知道它是一個巨集(Macro),語義也很直白,就是驗證我們放的兩個東西是不是一致(eq: equal),那怎麼跑呢,一樣下命令列即可:

~/demo-app$ cargo test
~/demo-app$ cargo test -p core # 執行指定專案名稱為 core 的測試

如果有跟著做完前一篇實作的同學應該會發現cargo watch很好用,約莫就是下圖的心情:

以後我吃不到怎辦

快把測試的批次指令加到我們的run批次檔裡面吧:

  • mac or linux:
    #!/bin/bash
    echo Run Options:
    echo 1: [core] demo play in console
    echo 2: [core] unit test                           # 加這行
    read VAR
    
    if [[ $VAR -eq 1 ]]
    then
      cargo watch -q -c -w ./core  -x 'run -p core --bin play'
      elif [[ $VAR -eq 2 ]]
      then
      cargo watch -q -c -w ./core -x 'test -p core'    # 和加這行
    fi
    
  • windows:
    Write-Host "輸入以下選項:"
    Write-Host "1) [core]: 在命令列試玩井字遊戲"
    Write-Host "2) [core]: 跑單元測試"                  # 加這行
    $opt = Read-Host ":"
    
    if ($opt -eq 1) {
         cargo watch -c -q -w ./core/src -x 'run -p core --bin play'
    } elseif ($opt -eq 2) {
        cargo watch -q -c -w ./core -x 'test -p core'  # 和這行
    }
    

實際運行起來結果如下:

單元測試結果

耶~都綠燈好開心,不過為什麼出現三排test result,下面還有 Doc-tests又是指什麼,稍後我們會提到。

整合測試

剛剛的單元測試是隨附在每個檔案裡的,就是小單元,只是測單個function或method對不對,如果要整個串起來,跨度大一點,就是屬於整合測試,rust裡的整合測試,會獨立放在另一個資料夾,通常叫會取名tests,雖然目前沒什麼好整合的,但我還是為了演示講一下,畢竟開發人員沒看到code就是沒fu(?,副駕駛該你上場了

檔案資料夾結構供參考:

~/demo-app$ tree core/
core/
├── Cargo.toml
├── src
│   ├── lib.rs
│   ├── play.rs
│   └── tic_tac_toe.rs
└── tests
    └── tic_tac_toe_integration_test.rs

整合測試這邊用兩個案例,就是依序跑完整個遊戲,其間驗證每一步驟是不是都有該有應該要有的狀態,在先前單元測試裡,所參照的遊戲模組,可以訪問私有的屬性,但在整合測試就不行,所以整合測試可以模擬別的模組在訪問(公開的介面)時,是不是可以順利完成相對應的功能:

// core/tests/tic_tac_toe_integration_test.rs
use core::tic_tac_toe::{Game, Symbol};

#[test]
fn test_game1_for_x_win() {          // 測試X贏的情境
    let mut game = Game::default();
    game.play(3).unwrap();           // O
    game.play(1).unwrap();           // X
    game.play(2).unwrap();           // O
    let err = game.play(1);          // 下到非空的格子
    assert_eq!(err.is_err(), true);  // 應該會回傳錯誤

    game.play(4).unwrap();           // X
    game.play(5).unwrap();           // O
    assert_eq!(game.is_over, false); // 確定分出勝負前的狀態
    assert_eq!(game.winner, None);   // 棋局尚未結束,且尚無贏家
    game.play(7).unwrap();           // X -> 連成一線 1, 4, 7
    assert_eq!(game.is_over, true);  // 棋局狀態為結束
    assert_eq!(game.winner, Some(Symbol::X)); // 棋局贏家為 X

    let err = game.play(6);
    assert_eq!(err.is_err(), true);
}

#[test]
fn test_game2_for_draw() {           // 模擬平手情境
    let mut game = Game::default();
    game.play(1).unwrap();           // O
    game.play(4).unwrap();           // X
    game.play(2).unwrap();           // O
    game.play(5).unwrap();           // X
    game.play(6).unwrap();           // O
    game.play(3).unwrap();           // X
    game.play(7).unwrap();           // O
    game.play(8).unwrap();           // X
    assert_eq!(game.is_over, false);
    game.play(9).unwrap();           // O
    assert_eq!(game.is_over, true);  // 棋局已結束
    assert_eq!(game.winner, None);   // 但無玩家勝出
}

完成上面的整合測試檔,這時如果終端還開著,會自動刷新,並且會出現整合測試檔的測試成功訊息:

整合測試結果

私有與可見性

我們來試一下剛剛講到訪問私有屬性的部分,如果這時候我們回去把Game的cells的pub拿掉:

 pub struct Game {
+    cells: [Option<Symbol>; 9],
-    pub cells: [Option<Symbol>; 9],

可以發現單元測試依然成功

單元測試改變

但如果把下面這段單元測試代碼,放到整合測試的檔案去跑的話:

// core/tests/tic_tac_toe_integration_tests
#[test]
fn test_access_game_cell() {
    let mut game = Game::default();
    game.cells = [
        Some(Symbol::O), Some(Symbol::O), Some(Symbol::O),
        Some(Symbol::X), Some(Symbol::X), None,
        None, None, None,
    ];
}

這時候就會報錯:

整合測試失敗情境

所以證實了剛剛所講的,整合測試檔會把我們實際程式當作另一個模組來訪問,私有部分就訪問不到。

這其實是來自rust的可見與私有特性的設定,私有的欄位或方法,只有在該模組或該模組底下的模組才可見,所以剛剛我們單元測試建立的mod是在該檔案內,等於子模組的概念,所以自然可見該檔的內容,這也方便了單元測試的調試。

rust的文檔註釋

還沒講剛剛看到的Doc-tests,那是什麼,其實就是他所講的doc tests(欸,我還是講中文吧),就是文件加測試(有講跟沒講一樣欸),測試剛剛帶大家跑過了,那文件呢,我們先講一下rust的文件怎麼寫,身為一個程式設計師,會寫Markdown也是很自然的一件事,所以rust讓我們直接在程式碼內嵌入markdown文件,對的,用法如下:

/// 與電腦對戰,`num`為玩家下的格號,1~9分別代表
/// [九宮格](https://zh.wikipedia.org/zh-tw/%E4%B9%9D%E5%AE%AE%E6%A0%BC)的位置,
/// 玩家下完後,電腦會隨機選擇一個空格劃記,直到遊戲結束,
/// 若玩家下的格號已被劃記,會回傳錯誤[`Error::AlreadyOccupied`],
/// 若遊戲已結束,會回傳錯誤[`Error::GameOver`],
/// 若一切正常,回傳Ok(())。
pub fn play_with_counter(&mut self, num: usize) -> Result<(), Error> {

只要在該struct, enum, field, variant, fn上面加///編寫的文件,就可以利用rust內建的rustdoc幫我們產生文件,

就像C#的XML文件註解,或JSDoc

寫好相關註釋之後(有點多,在此我不逐項列出,好奇的人可以參考專案原始碼),接著我們把 rust 的文件跑起來看看長啥模樣:

~/demo-app$ cargo doc -p core --open # -p 指定要產文件的專案, --open 產完自動開啟

rust 文件長相

產完會長這樣,這也是我們在查詢別的crates套件文件,經常會看到類似的畫面,對比一下剛剛的範例,看一下產生出文件中的play_with_counter,可以看到的九宮格變成跟這邊一樣可以點擊的連結,就像一般MarkDown一樣;而後面兩個連結連到不同的Error物件,在rust裡用[`__`]的寫法可以連結到該指定物件的文檔。值得注意的一點是,如果參照到別的crate庫,在引用範圍內也可以連結過去,如下面的None

rust文檔範例2

另外眼尖的朋友應該有發現到右上角的source,是不是可以直接開啟對應的原始碼呢:

文件導引原始碼

的確是這樣的,有時候看文件的說明還是不太明白就可以直接看code,聽起來還不錯,但其實rust的文檔還有一項功能,就是把文件和測試結合在一起的doc test(活文件?)。

Rust 文檔測試 Doc Test

直接講用法,剛剛提到文件格式既然是markdown,那麼在裡面加code block代碼區塊是很正常的,所以:

/// 取得目前步數,用於判斷現在是輪到哪一方符號。
/// 以下範例,預設第一手`O`,所以第一步後,步數為1,後續依下棋次數遞增:
/// ```
/// # use core::tic_tac_toe::{Game, Symbol};
/// let mut game = Game::default();
/// assert_eq!(game.current_step(), 0);
/// game.play(1).unwrap();
/// assert_eq!(game.current_step(), 1);
/// game.play(2).unwrap();
/// assert_eq!(game.current_step(), 2);
/// ```
pub fn current_step(&self) -> usize {

重新執行cargo doc以產生文件,再去刷新文件的頁面,可以看到異動後的結果:

含測試的註釋文檔

看起來還不錯耶,加了一個代碼區塊範例,讓閱讀的人比較容易理解,畢竟有時候文件說明太抽象,看code最直接。這時回到剛剛一直開在一旁的test console,可以看到:

文檔測試結果

剛剛測試裡面的Doc-tests 多了一條測試出來,就是我們剛剛寫在文件裡的範例代碼,(強迫你圖文合一,文件要跟代碼一致的概念? 才不會被噴圖文不符),我們再替fn play 加上說明及測試看看,這次有不同情境,所以我們需要分2個程式碼區塊來分別表達:

/// 下棋,`num`為指定劃記的格號,1~9分別代表九宮格的位置,
/// 若指定的格號已被劃記,會回傳錯誤[`Error::AlreadyOccupied`],
/// 若遊戲已結束,會回傳錯誤[`Error::GameOver`],
/// 若無報錯,會將格號劃記為當前劃記符號,並檢查遊戲是否結束,
/// 若一切正常,回傳`Ok(())`。
/// 範例,以下在第1格下棋後,預設第一手`O`,所以第1格(陣列索引第0項)會畫上`O`:
/// ```
/// use core::tic_tac_toe::{Game, Symbol};
/// let mut game = Game::default();
/// game.play(1).unwrap();
/// assert_eq!(game.cells[0], Some(Symbol::O));
/// ```
/// 如果我們再下第二手,預設第二手`X`,所以下例第8格(陣列索引第7項)會畫上`X`:
/// ```
/// use core::tic_tac_toe::{Game, Symbol};
/// let mut game = Game::default();
/// game.play(1).unwrap();
/// assert_eq!(game.cells[0], Some(Symbol::O));
/// game.play(8).unwrap();
/// assert_eq!(game.cells[7], Some(Symbol::X));
/// ```
pub fn play(&mut self, num: usize) -> Result<(), Error> {

測試沒有意外地通過了:
更多案例的文檔測試結果

文檔也順利的產出:
更多案例的測試文檔

因為每個代碼區塊都是獨立運行的測試,所以我們要一直重覆寫初始化的部分,如use, let .. = ..等等,這樣對於整個文件的閱讀來說有點小干擾,那麼怎辬呢,解法是在code block裡加#,使其在markdown文檔裡失效,但測試程式依然會跑:

/// 範例,以下在第1格下棋後,預設第一手`O`,所以第1格(陣列索引第0項)會畫上`O`:
/// ```
/// # use core::tic_tac_toe::{Game, Symbol};
/// let mut game = Game::default();
/// game.play(1).unwrap();
/// assert_eq!(game.cells[0], Some(Symbol::O));
/// ```
/// 如果我們再下第二手,預設第二手`X`,所以下例第8格(陣列索引第7項)會畫上`X`:
/// ```
/// # use core::tic_tac_toe::{Game, Symbol};
/// # let mut game = Game::default();
/// # game.play(1).unwrap();
/// # assert_eq!(game.cells[0], Some(Symbol::O));
/// game.play(8).unwrap();
/// assert_eq!(game.cells[7], Some(Symbol::X));
/// ```

簡化後rust測試文檔

如此我們的文檔會更清晰,同時測試代碼也可以順利運行。附帶一提,如果有人覺得測試跑起來太長太多的話,可以叫它安靜(quiet)一點:

~/demo-app$ cargo test -p core -q   # 加上 q(uiet) 參數

結果如下:

安靜版測試結果

就只會依檔案列出每個檔案執行的結果,成功的案例就不會再逐條細項列出,只有測試失敗才會提示,如下:

安靜版測試失敗示意圖

封裝

一些有OO物件導向經驗的的朋友想必從上例發現一個問題,就是我們的資料結構都是 pub,似乎並沒有封裝啊。好吧,如果有人堅持要封裝的話,rust的作法其實很簡單,就是在該結構體下實作同名fn,在把結構體裡的pub拿掉即可,這樣變成getter(或get property)了。

impl Game {
    pub fn cells(&self) -> [Option<Symbol>; 9] { self.cells }
    pub fn is_over(&self) -> bool { self.is_over }
    pub fn winner(&self) -> Option<Symbol> { self.winner }
    pub fn symbols(&self) -> [Symbol; 2] { self.symbols }
}

使用上也很簡單,原本呼叫欄位,加一個小括號()就可以呼叫了,範例如下:

let game = Game::default();
let cells = game.cells;    // field 欄位
let cells = game.cells();  // fn (方法)

小結

相信大家已經開始對rust的語法越來越熟悉了,也大略知道rust文件生態,可以曉得怎麼去看別人的文件,應該可以自己開始實作一些簡單的功能(?)了,下一篇我們來實作後端的接口。另外這邊就先省略不實作對戰時,怎麼樣使電腦聰明下棋的演算法了,有興趣的朋友可以試著自己挑戰一下。

參考資料


上一篇
05 利用 rust 完成井字遊戲... 啊不就只是個小遊戲?
下一篇
07 熟悉的 rest api 最對味,feat. rust
系列文
前端? 後端? 摻在一起做成全端就好了30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言